/** * User management detail endpoint * * GET /_emdash/api/admin/users/:id - Get user details * PUT /_emdash/api/admin/users/:id - Update user */ import { builtinRoleForLevel, Role } from "@premium-cms/auth"; import { createKyselyAdapter } from "@premium-cms/auth/adapters/kysely"; import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; import { userUpdateBody } from "#api/schemas.js"; import { invalidateAuthzCache } from "#auth/authz.js"; import { AuthzRepository } from "#db/repositories/authz.js"; import { withTransaction } from "#db/transaction.js"; export const prerender = false; export const GET: APIRoute = async ({ params, locals }) => { const { emdash, user: currentUser } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } const denied = requirePerm(currentUser, "users:read"); if (denied) return denied; const adapter = createKyselyAdapter(emdash.db); const { id } = params; if (!id) { return apiError("MISSING_PARAM", "User ID required", 400); } try { const result = await adapter.getUserWithDetails(id); if (!result) { return apiError("NOT_FOUND", "User not found", 404); } // Transform for JSON serialization const item = { id: result.user.id, email: result.user.email, name: result.user.name, avatarUrl: result.user.avatarUrl, role: result.user.role, roleId: result.user.roleId, emailVerified: result.user.emailVerified, disabled: result.user.disabled, createdAt: result.user.createdAt.toISOString(), updatedAt: result.user.updatedAt.toISOString(), lastLogin: result.lastLogin?.toISOString() ?? null, credentials: result.credentials.map((c) => ({ id: c.id, name: c.name, deviceType: c.deviceType, createdAt: c.createdAt.toISOString(), lastUsedAt: c.lastUsedAt.toISOString(), })), oauthAccounts: result.oauthAccounts.map((a) => ({ provider: a.provider, createdAt: a.createdAt.toISOString(), })), }; return apiSuccess({ item }); } catch (error) { return handleError(error, "Failed to get user details", "USER_DETAIL_ERROR"); } }; export const PUT: APIRoute = async ({ params, request, locals }) => { const { emdash, user: currentUser } = locals; if (!emdash?.db) { return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); } const denied = requirePerm(currentUser, "users:manage"); if (denied) return denied; const adapter = createKyselyAdapter(emdash.db); const { id } = params; if (!id) { return apiError("MISSING_PARAM", "User ID required", 400); } try { // Get target user const targetUser = await adapter.getUserById(id); if (!targetUser) { return apiError("NOT_FOUND", "User not found", 404); } const body = await parseBody(request, userUpdateBody); if (isParseError(body)) return body; // Resolve the target role. `roleId` (id or slug) wins; the legacy // `role` level maps to the built-in role for that tier. Either way the // user's level column follows the role so pre-policy code keeps a floor. let role = body.role; let roleId: string | undefined; if (body.roleId !== undefined) { const summary = await new AuthzRepository(emdash.db).getRole(body.roleId); if (!summary) return apiError("UNKNOWN_ROLE", "Role not found", 400); roleId = summary.id; role = summary.level as typeof role; } else if (role !== undefined) { roleId = `role:${builtinRoleForLevel(role).slug}`; } // Prevent editing own role (security: prevents self-demotion/lockout) if (roleId !== undefined && id === currentUser!.id) { return apiError("SELF_ROLE_CHANGE", "Cannot change your own role", 400); } // Check email uniqueness if changing email if (body.email && body.email !== targetUser.email) { const existing = await adapter.getUserByEmail(body.email); if (existing) { return apiError("EMAIL_IN_USE", "Email already in use", 409); } } // Wrap admin demotion guard + update in a transaction to prevent // two concurrent demotions from both passing the count check. const isDemotingAdmin = role !== undefined && role < Role.ADMIN && targetUser.role === Role.ADMIN; const lastAdminBlocked = await withTransaction(emdash.db, async (trx) => { const trxAdapter = createKyselyAdapter(trx); if (isDemotingAdmin) { const adminCount = await trxAdapter.countAdmins(); if (adminCount <= 1) return true; } await trxAdapter.updateUser(id, { name: body.name, email: body.email, role, roleId, }); return false; }); invalidateAuthzCache(); if (lastAdminBlocked) { return apiError( "LAST_ADMIN", "Cannot demote the last admin. Promote another user first.", 400, ); } // Fetch updated user const updated = await adapter.getUserById(id); return apiSuccess({ item: { id: updated!.id, email: updated!.email, name: updated!.name, avatarUrl: updated!.avatarUrl, role: updated!.role, roleId: updated!.roleId, emailVerified: updated!.emailVerified, disabled: updated!.disabled, createdAt: updated!.createdAt.toISOString(), updatedAt: updated!.updatedAt.toISOString(), }, }); } catch (error) { return handleError(error, "Failed to update user", "USER_UPDATE_ERROR"); } };